Skip to content

add Settings domain with DB persistence and admin CRUD API#939

Merged
bitcoin-coder-bob merged 50 commits into
masterfrom
bob/settings-domain-and-repo
Jun 12, 2026
Merged

add Settings domain with DB persistence and admin CRUD API#939
bitcoin-coder-bob merged 50 commits into
masterfrom
bob/settings-domain-and-repo

Conversation

@bitcoin-coder-bob

@bitcoin-coder-bob bitcoin-coder-bob commented Feb 27, 2026

Copy link
Copy Markdown
Collaborator

closes #934

  • Introduce a Settings domain model that moves 16 runtime-tunable config values (ban threshold/duration, exit delays, VTXO tree expiry, round participant limits, amount limits, etc.) out of environment
    variables and into the database
  • Validate all settings fields (amount bounds, participant limits, exit delays, ban thresholds, update_fields field names) with descriptive errors
  • Add SettingsRepository with implementations for Postgres, SQLite, and Badger, including SQL migrations
  • Add three new Admin gRPC/REST endpoints: GetSettings, UpdateSettings (allows for partial updates), and ClearSettings
  • On startup, seed default settings to the DB if none exist (scheduler-aware: block vs time-based defaults)
  • Settings changes propagate live to the running app service via an UpdateSettings callback — no restart required
  • Remove the corresponding env vars from config, docker-compose, and .env files

Summary by CodeRabbit

  • New Features

    • Admin API to get, update, and clear runtime settings with persisted defaults and live-apply support; runtime service now accepts live setting changes.
  • Documentation

    • Added Admin Settings API docs and comprehensive settings table; removed several legacy environment variables from docs.
  • Chores

    • Simplified local run-simulation startup (fixed session duration) and adjusted compose/dev envs (removed some env vars, added CSV allow flag).
  • Tests

    • Added unit tests for settings repo/service and updated E2E setup to apply admin settings automatically.

@coderabbitai

coderabbitai Bot commented Feb 27, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a persistent, runtime-configurable Settings domain with Get/Update/Clear admin APIs, DB migrations and repo implementations (badger/sqlite/postgres), wiring to load/apply settings at startup and runtime, plus docs, Makefile, env and e2e test updates to use admin-managed settings.

Changes

Cohort / File(s) Summary
API Spec & Protos
api-spec/openapi/swagger/ark/v1/admin.openapi.json, api-spec/protobuf/ark/v1/admin.proto
Add GET/POST /v1/admin/settings and POST /v1/admin/settings/clear endpoints and corresponding request/response schemas/messages for Settings.
gRPC Handlers & Permissions
internal/interface/grpc/handlers/adminservice.go, internal/interface/grpc/permissions/permissions.go
Add gRPC handlers for GetSettings/UpdateSettings/ClearSettings and map RPCs to EntityManager permissions.
Domain & Repo Contracts
internal/core/domain/settings.go, internal/core/domain/settings_repo.go, internal/core/ports/repo_manager.go
Introduce domain.Settings model, validation and locktime helper; add SettingsRepository interface and RepoManager.Settings() accessor.
Application Layer
internal/core/application/admin.go, internal/core/application/admin_test.go, internal/core/application/service.go, internal/core/application/types.go
Add AdminService Get/Update/Clear methods with onSettingsUpdated callback; add service.UpdateSettings runtime application logic and unit tests.
Config & Wiring
internal/config/config.go
Remove many env-based keys, add defaultSettings load/seed flow, and pass onSettingsUpdated callback into admin service to propagate runtime changes.
DB Migrations & SQLC
internal/infrastructure/db/*/migration/20260227000000_add_settings.*, internal/infrastructure/db/*/sqlc/query.sql, .../sqlc/queries/*
Add settings table migrations; SQLC queries/models for UpsertSettings, SelectLatestSettings, ClearSettings and Setting/UpsertSettingsParams types.
Repo Implementations
internal/infrastructure/db/badger/settings_repo.go, internal/infrastructure/db/postgres/settings_repo.go, internal/infrastructure/db/sqlite/settings_repo.go
Add Badger/Postgres/SQLite SettingsRepository implementations (Get/Upsert/Clear/Close); badger Upsert includes retry logic.
DB Service Integration & Tests
internal/infrastructure/db/service.go, internal/infrastructure/db/service_test.go
Wire settingsStore factory into infra DB service, expose service.Settings(), ensure cleanup, and add tests exercising settings repo lifecycle.
E2E Tests & Helpers
internal/test/e2e/utils_test.go, internal/test/e2e/single_batch_smoke_test.go
Add updateSettings helper invoked during test setup to apply admin settings; remove env-var references from comments.
Docs, Makefile & Env files
README.md, Makefile, docker-compose.regtest.yml, envs/arkd.dev.env, envs/arkd.light.env
README gains Admin Settings docs and settings table; many ARKD_* env-vars removed from docs and env files; Makefile run-simulation simplified and regtest toggles ARKD_ALLOW_CSV_BLOCK_TYPE.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant GRPC as "gRPC Handler"
    participant AdminApp as "Admin Service"
    participant RepoMgr as "Repo Manager"
    participant SettingsRepo as "Settings Repo"
    participant AppSvc as "App Service"

    Client->>GRPC: UpdateSettings(request)
    GRPC->>AdminApp: UpdateSettings(settings)
    AdminApp->>AdminApp: validate & set UpdatedAt
    AdminApp->>RepoMgr: Settings()
    RepoMgr-->>AdminApp: SettingsRepository
    AdminApp->>SettingsRepo: Upsert(settings)
    SettingsRepo->>SettingsRepo: persist to storage
    SettingsRepo-->>AdminApp: OK
    AdminApp->>AppSvc: onSettingsUpdated / UpdateSettings(settings)
    AppSvc->>AppSvc: recalc derived values & tapscript
    AppSvc-->>AdminApp: OK
    AdminApp-->>GRPC: UpdateSettingsResponse
    GRPC-->>Client: Response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • altafan
  • louisinger
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main objective: introducing a Settings domain model with database persistence and admin CRUD API endpoints.
Linked Issues check ✅ Passed The PR comprehensively implements all coding requirements from issue #934: Settings domain and repository interface with CRUD operations, persistent storage (Postgres/SQLite/Badger), three admin gRPC/REST endpoints (GetSettings/UpdateSettings/ClearSettings), seeding defaults on startup, live propagation of changes, and removal of corresponding environment variables.
Out of Scope Changes check ✅ Passed All changes align with the linked issue scope. Code modifications are limited to implementing the Settings domain, repository, migrations, handlers, and updating configuration workflows. Unrelated refactoring or feature additions are absent.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/settings-domain-and-repo

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review March 3, 2026 16:26
@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

🔍 Arkana PR Review — arkd#939: Settings Domain with DB Persistence

Scope: Moves 16 runtime-tunable config values (ban threshold/duration, exit delays, VTXO tree expiry, round participant limits, amount limits, etc.) from environment variables into a DB-persisted Settings domain with admin CRUD API. Touches 38 files, +2413/−199 lines.

Overall: Well-structured domain model following the existing repository pattern. Clean separation across Postgres, SQLite, and Badger backends with proper migrations. Good test coverage. A few concerns below, some security-relevant.


🔴 Security: No Input Validation on UpdateSettings

internal/interface/grpc/handlers/adminservice.goUpdateSettings passes proto values straight through without any validation. Since proto3 uses zero values for unset fields, a partial update attempt (e.g., only setting ban_threshold) would zero out all other fields including critical ones like unilateral_exit_delay, boarding_exit_delay, and vtxo_tree_expiry.

Risks:

  • Setting unilateral_exit_delay = 0 or boarding_exit_delay = 0 would break the unilateral exit guarantee — users could lose the ability to safely exit.
  • Setting vtxo_tree_expiry = 0 would create immediately-expired VTXO trees.
  • Setting max_tx_weight = 0 would reject all transactions.
  • Negative or nonsensical values for delays/amounts aren't rejected.

Suggestion: Add a Validate() method on domain.Settings that enforces minimum safe values, and call it in UpdateSettings before persisting. At minimum: exit delays > 0, vtxo_tree_expiry > unilateral_exit_delay, boarding_exit_delay > vtxo_tree_expiry, etc. The existing validation in config.go:Validate() already checks some of these constraints — factor those checks into a shared function.

Also consider using proto optional fields or a field mask to distinguish "set to zero" from "not provided", enabling safe partial updates.


🟡 Bug: ClearSettings Doesn't Reset Running Service

internal/core/application/admin.goClearSettings calls repo.Clear() and onInfoChange() but does not call onSettingsUpdated or reset the in-memory service settings to defaults. The running arkd keeps the previously loaded settings until restart. This is inconsistent with the "live propagation" behavior of UpdateSettings.

func (a *adminService) ClearSettings(ctx context.Context) error {
    if err := a.repoManager.Settings().Clear(ctx); err != nil {
        return err
    }
    // Missing: reset service to default settings
    a.onInfoChange()
    return nil
}

Should either: (1) re-seed defaults and call onSettingsUpdated with them, or (2) document that ClearSettings requires a restart and update the API docs accordingly.


🟡 Duplicated Locktime Logic

internal/core/application/service.go:toRelativeLocktime() duplicates the block-vs-time threshold logic from config.go:determineLocktimeType() with a hardcoded minAllowedSequence = 512. If this threshold ever changes in one place but not the other, settings loaded at startup vs settings updated via API would interpret the same values differently.

Suggestion: Extract the shared logic into a single function in domain or a shared package.


🟢 Positives

  • Clean architecture: Domain model → repository interface → 3 backend implementations follows the existing patterns well.
  • Live propagation: UpdateSettings callback recalculates derived values (dust amounts, checkpoint tapscript) correctly in service.go:UpdateSettings().
  • Good test coverage: Unit tests for admin service, integration tests for all 3 DB backends, e2e tests updated to use the new API.
  • Proper auth gating: New endpoints use EntityManager read/write permissions consistent with other admin endpoints.
  • Migration files: Present for both Postgres and SQLite with proper up/down scripts.
  • README updated: Documents the new admin settings API with defaults table.

Minor Notes

  • Postgres UpsertSettings always inserts with id=0 (Go zero value). Works because GENERATED BY DEFAULT AS IDENTITY allows explicit values, but it's implicit — a comment would help.
  • The NewAdminService signature now requires an onSettingsUpdated callback — breaking change for any external consumers of that constructor.
  • settings table schema uses BIGINT NOT NULL DEFAULT 0 for all fields. Since -1 is a valid sentinel for amount limits ("unset"/"dust"), the DEFAULT 0 in the schema doesn't match the domain semantics. Not a functional bug (the app always provides explicit values), but could be confusing.

Verdict: The architectural approach is solid and the implementation is thorough. The missing input validation on UpdateSettings is the most critical item — it's a live admin API that directly controls protocol-security parameters (exit delays, VTXO expiry). Would recommend addressing the validation gap before merge.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (5)
Makefile (2)

184-200: Remove stale MIN/MAX handling from run-simulation output and shell vars.

Line 197-Line 199 compute/log MIN and MAX, but they are never passed to the test command. In internal/test/e2e/single_batch_smoke_test.go (Line 23-Line 65), only -num-clients is parsed, while min/max participants are hardcoded in test config. This log is misleading.

Proposed cleanup
 	`@bash` -c '\
 		CLIENTS="$${CLIENTS:-5}"; \
-		MIN="$${MIN:-$$CLIENTS}"; \
-		MAX="$${MAX:-128}"; \
-		echo "Running batch settlement test with $$CLIENTS clients (MIN=$$MIN, MAX=$$MAX)..."; \
+		echo "Running batch settlement test with $$CLIENTS clients..."; \
 		go test -v -count=1 -timeout 1200s github.com/arkade-os/arkd/internal/test/e2e -run TestBatchSettleMultipleClients -args -smoke -num-clients=$$CLIENTS; \
 	'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Makefile` around lines 184 - 200, The run-simulation Makefile target prints
and sets unused MIN and MAX variables which is misleading; remove the MIN and
MAX assignments and their use in the echo so the target only reads CLIENTS and
passes -num-clients to the test. Update the run-simulation recipe's shell block
to drop MIN="$${MIN:-$$CLIENTS}" and MAX="$${MAX:-128}" and change the echo to
only reference $$CLIENTS (leaving the go test invocation that runs
TestBatchSettleMultipleClients unchanged); this aligns the Makefile with the
test in internal/test/e2e/single_batch_smoke_test.go which only parses
-num-clients.

191-192: Make ARKD_SESSION_DURATION overrideable while keeping default 60.

Line 192 hardcodes ARKD_SESSION_DURATION=60. Consider defaulting to 60 but allowing callers to override (make run-simulation ARKD_SESSION_DURATION=120) for troubleshooting slower environments.

Proposed tweak
-	`@ARKD_SESSION_DURATION`=60 docker compose -f docker-compose.regtest.yml up --build -d
+	`@ARKD_SESSION_DURATION`=$${ARKD_SESSION_DURATION:-60} docker compose -f docker-compose.regtest.yml up --build -d
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Makefile` around lines 191 - 192, The recipe currently hardcodes
ARKD_SESSION_DURATION=60 in the Docker command; make this
default-but-overridable by either (a) adding a Makefile variable declaration
like ARKD_SESSION_DURATION ?= 60 and using ARKD_SESSION_DURATION in the recipe,
or (b) using shell expansion in the recipe
ARKD_SESSION_DURATION=${ARKD_SESSION_DURATION:-60} docker compose ... so callers
can run make run-simulation ARKD_SESSION_DURATION=120 to override while keeping
60 as the default.
internal/core/application/service.go (1)

304-349: Consider thread safety for runtime settings updates.

UpdateSettings modifies multiple service fields that may be read concurrently by round processing goroutines (e.g., banDuration, roundMinParticipantsCount, checkpointExitDelay). Without synchronization, this could cause data races.

Options to consider:

  1. Document that settings changes take effect between rounds (eventual consistency)
  2. Add a mutex to protect settings reads/writes
  3. Use atomic operations for individual fields

If the current behavior (unsynchronized updates) is intentional for simplicity, consider adding a comment documenting when it's safe to call this method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/service.go` around lines 304 - 349, The
UpdateSettings method mutates many runtime fields (e.g., banDuration,
banThreshold, unilateralExitDelay, publicUnilateralExitDelay,
checkpointExitDelay, boardingExitDelay, batchExpiry, roundMinParticipantsCount,
roundMaxParticipantsCount, vtxoMinAmount, vtxoMaxAmount, utxoMinAmount,
utxoMaxAmount, settlementMinExpiryGap, vtxoNoCsvValidationCutoffTime,
maxTxWeight, checkpointTapscript, forfeitPubkey) without synchronization; add a
sync.RWMutex (e.g., settingsMu) to the service struct and wrap the entire
UpdateSettings body with settingsMu.Lock()/Unlock(), and update all readers of
those fields (round processing goroutines, any methods reading these fields) to
use settingsMu.RLock()/RUnlock(); alternatively, if unsynchronized updates are
intentional, add a clear comment on UpdateSettings documenting that it must only
be called between rounds and is not concurrency-safe.
internal/core/domain/settings.go (1)

25-55: Avoid the 16-argument positional constructor.

All of these parameters are int64, so a single swap still compiles and silently assigns the wrong setting. A dedicated input struct (or plain struct literal at call sites) is much safer to review and extend.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/domain/settings.go` around lines 25 - 55, The NewSettings
function takes 16 positional int64 arguments which risks silent swaps; replace
it with a single explicit config struct (e.g. SettingsConfig or SettingsOptions)
containing named fields matching Settings (BanThreshold, BanDuration,
UnilateralExitDelay, PublicUnilateralExitDelay, CheckpointExitDelay,
BoardingExitDelay, VtxoTreeExpiry, RoundMinParticipantsCount,
RoundMaxParticipantsCount, VtxoMinAmount, VtxoMaxAmount, UtxoMinAmount,
UtxoMaxAmount, SettlementMinExpiryGap, VtxoNoCsvValidationCutoffDate,
MaxTxWeight), change NewSettings signature to accept that struct (or a pointer)
and construct &Settings from the struct fields, and update all callers to pass a
struct literal (or use functional options) instead of positional args so each
value is explicit and reviewable.
api-spec/protobuf/ark/v1/admin.proto (1)

381-408: Keep updated_at out of the update payload.

internal/interface/grpc/handlers/adminservice.go ignores req.settings.updated_at, so reusing the same Settings message for reads and writes advertises a writable field the server never honors. A dedicated update message keeps the contract accurate and avoids clients round-tripping server-managed data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api-spec/protobuf/ark/v1/admin.proto` around lines 381 - 408, The proto
reuses message Settings for both reads and writes while server ignores
req.settings.updated_at; change the write contract so clients cannot send
updated_at by adding a dedicated update message (e.g., message SettingsUpdate or
SettingsWritable) that mirrors Settings but omits the updated_at field, then
change UpdateSettingsRequest to use that new message instead of Settings; update
any generated stubs/clients and ensure adminservice.go handler continues to
operate unchanged (and that server-managed updated_at remains set server-side).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api-spec/openapi/swagger/ark/v1/admin.openapi.json`:
- Around line 1695-1768: The UpdateSettingsRequest/Settings schema must be
updated at the proto/message level to match the handler contract: decide whether
the endpoint is a full-replace or a patch and implement that explicitly—if
full-replace, make UpdateSettingsRequest.settings required and make each
Settings field required (remove writable updatedAt or mark updatedAt as
output/readOnly); if patch, change Settings fields to use nullable wrapper types
(e.g., google.protobuf.Int64Value) or a FieldMask so omitted fields are
distinguishable from zero and won’t overwrite stored values; update the Settings
message to not expose updatedAt as an input field (mark it output-only) and then
regen the OpenAPI/JSON so UpdateSettingsRequest aligns with adminservice's
behavior (refer to UpdateSettingsRequest, Settings, and the adminservice handler
logic).

In `@internal/core/application/admin.go`:
- Around line 634-639: ClearSettings removes persisted settings but doesn't
update the in-memory runtime config; call the settings update handler after
clearing so the node re-seeds defaults and applies the change. Modify
ClearSettings (the function that calls a.repoManager.Settings().Clear) to invoke
a.onSettingsUpdated() (in addition to or instead of a.onInfoChange())
immediately after the clear succeeds so the in-memory settings and any
subscribers are refreshed (Config.loadSettings is the startup equivalent to
emulate).
- Around line 620-630: The UpdateSettings flow currently persists settings
(repoManager.Settings().Upsert) before running live-apply guards, which can lead
to races and invalid persisted values; change UpdateSettings (method
adminService.UpdateSettings) to serialize concurrent updates (add and use a
mutex field on adminService e.g., updateSettingsMu) and perform
validation/live-apply before making durable: acquire the mutex, run the same
validation/startup checks from internal/config/config.go and call
a.onSettingsUpdated(ctx, settings) while still in-memory, only if
validation/onSettingsUpdated succeeds then set settings.UpdatedAt and call
repoManager.Settings().Upsert(ctx, settings), finally release the mutex and call
a.onInfoChange(); propagate errors from validation/apply/upsert unchanged.
- Around line 620-631: UpdateSettings currently writes settings but does not
refresh the cached participant defaults
(roundMinParticipantsCount/roundMaxParticipantsCount), so subsequent
UpdateScheduledSessionConfig calls may still use startup defaults; after
successfully upserting settings and invoking a.onSettingsUpdated, update the
adminService cached fields by assigning a.roundMinParticipantsCount and
a.roundMaxParticipantsCount from the new settings (or call an existing helper
that derives those defaults), ensuring this happens before a.onInfoChange() so
future calls to UpdateScheduledSessionConfig use the new values.

In `@internal/core/application/service.go`:
- Around line 295-302: Add a clarifying doc comment above the toRelativeLocktime
function explaining why values >= minAllowedSequence (512) are treated as
seconds and values < 512 as blocks; mention the BIP68 granularity (512-second
units) and that configuration validation enforces time-based values be multiples
of 512 so this threshold determines whether arklib.RelativeLocktime uses
arklib.LocktimeTypeSecond vs arklib.LocktimeTypeBlock.

In `@internal/infrastructure/db/badger/settings_repo.go`:
- Around line 64-79: The Upsert method in settingsRepository currently retries
on badger.ErrConflict without honoring cancellation; modify Upsert to stop
retrying as soon as ctx is canceled by checking ctx.Done() between attempts and
before sleeping/attempting another r.store.Upsert, returning ctx.Err() if
canceled; preserve existing retry count logic (maxRetries) and continue only
while ctx is active, referencing the Upsert method, settingsKey, maxRetries and
the same badger.ErrConflict check.

In `@internal/infrastructure/db/service.go`:
- Around line 229-232: The settings-store initialization can fail after the DB
connection and earlier repositories are created, leaving those handles open;
wrap resource creation in a cleanup stack or use a defer-until-success pattern:
after creating the DB connection and each repository, push their corresponding
cleanup functions (e.g., db.Close()/Disconnect and repo.Close()/Shutdown) onto a
slice of funcs, and if settingsStoreFactory(...) returns an error, iterate that
slice to call each cleanup before returning the error; apply the same pattern
around the other failing sites noted (the analogous blocks at the later
occurrences referenced) so all partially-initialized resources are closed on
error.

In `@internal/interface/grpc/handlers/adminservice.go`:
- Around line 589-621: The UpdateSettings handler (adminHandler.UpdateSettings)
must validate invariants before calling a.adminService.UpdateSettings: ensure
numeric/duration fields are within acceptable bounds (non-negative for
BanThreshold, BanDuration, UnilateralExitDelay, PublicUnilateralExitDelay,
CheckpointExitDelay, BoardingExitDelay, VtxoTreeExpiry, SettlementMinExpiryGap,
MaxTxWeight), ensure participant counts satisfy RoundMinParticipantsCount <
RoundMaxParticipantsCount, ensure min/max pairs are ordered (VtxoMinAmount <=
VtxoMaxAmount, UtxoMinAmount <= UtxoMaxAmount), and validate any date fields
(VtxoNoCsvValidationCutoffDate) as needed; if any check fails return
status.Error(codes.InvalidArgument, "...") with a clear message. Implement these
checks at the start of adminHandler.UpdateSettings (before calling
a.adminService.UpdateSettings / Upsert) and use the same status codes/messages
pattern as other handlers.

In `@README.md`:
- Around line 102-122: The README admin-settings section is out of sync: update
the documentation to list and describe the newly added endpoints GET
/v1/admin/settings and POST /v1/admin/settings/clear in addition to POST
/v1/admin/settings, and extend the settings table to include the two missing
persisted settings settlement_min_expiry_gap and
vtxo_no_csv_validation_cutoff_date with their descriptions and default values;
ensure the table rows use the same formatting as existing entries (backticked
keys like `settlement_min_expiry_gap`, `vtxo_no_csv_validation_cutoff_date`) and
mention which endpoints manage them.

---

Nitpick comments:
In `@api-spec/protobuf/ark/v1/admin.proto`:
- Around line 381-408: The proto reuses message Settings for both reads and
writes while server ignores req.settings.updated_at; change the write contract
so clients cannot send updated_at by adding a dedicated update message (e.g.,
message SettingsUpdate or SettingsWritable) that mirrors Settings but omits the
updated_at field, then change UpdateSettingsRequest to use that new message
instead of Settings; update any generated stubs/clients and ensure
adminservice.go handler continues to operate unchanged (and that server-managed
updated_at remains set server-side).

In `@internal/core/application/service.go`:
- Around line 304-349: The UpdateSettings method mutates many runtime fields
(e.g., banDuration, banThreshold, unilateralExitDelay,
publicUnilateralExitDelay, checkpointExitDelay, boardingExitDelay, batchExpiry,
roundMinParticipantsCount, roundMaxParticipantsCount, vtxoMinAmount,
vtxoMaxAmount, utxoMinAmount, utxoMaxAmount, settlementMinExpiryGap,
vtxoNoCsvValidationCutoffTime, maxTxWeight, checkpointTapscript, forfeitPubkey)
without synchronization; add a sync.RWMutex (e.g., settingsMu) to the service
struct and wrap the entire UpdateSettings body with settingsMu.Lock()/Unlock(),
and update all readers of those fields (round processing goroutines, any methods
reading these fields) to use settingsMu.RLock()/RUnlock(); alternatively, if
unsynchronized updates are intentional, add a clear comment on UpdateSettings
documenting that it must only be called between rounds and is not
concurrency-safe.

In `@internal/core/domain/settings.go`:
- Around line 25-55: The NewSettings function takes 16 positional int64
arguments which risks silent swaps; replace it with a single explicit config
struct (e.g. SettingsConfig or SettingsOptions) containing named fields matching
Settings (BanThreshold, BanDuration, UnilateralExitDelay,
PublicUnilateralExitDelay, CheckpointExitDelay, BoardingExitDelay,
VtxoTreeExpiry, RoundMinParticipantsCount, RoundMaxParticipantsCount,
VtxoMinAmount, VtxoMaxAmount, UtxoMinAmount, UtxoMaxAmount,
SettlementMinExpiryGap, VtxoNoCsvValidationCutoffDate, MaxTxWeight), change
NewSettings signature to accept that struct (or a pointer) and construct
&Settings from the struct fields, and update all callers to pass a struct
literal (or use functional options) instead of positional args so each value is
explicit and reviewable.

In `@Makefile`:
- Around line 184-200: The run-simulation Makefile target prints and sets unused
MIN and MAX variables which is misleading; remove the MIN and MAX assignments
and their use in the echo so the target only reads CLIENTS and passes
-num-clients to the test. Update the run-simulation recipe's shell block to drop
MIN="$${MIN:-$$CLIENTS}" and MAX="$${MAX:-128}" and change the echo to only
reference $$CLIENTS (leaving the go test invocation that runs
TestBatchSettleMultipleClients unchanged); this aligns the Makefile with the
test in internal/test/e2e/single_batch_smoke_test.go which only parses
-num-clients.
- Around line 191-192: The recipe currently hardcodes ARKD_SESSION_DURATION=60
in the Docker command; make this default-but-overridable by either (a) adding a
Makefile variable declaration like ARKD_SESSION_DURATION ?= 60 and using
ARKD_SESSION_DURATION in the recipe, or (b) using shell expansion in the recipe
ARKD_SESSION_DURATION=${ARKD_SESSION_DURATION:-60} docker compose ... so callers
can run make run-simulation ARKD_SESSION_DURATION=120 to override while keeping
60 as the default.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0ef746c5-f1c4-43ba-8188-e57d581bb56a

📥 Commits

Reviewing files that changed from the base of the PR and between 137daae and da53ecf.

⛔ Files ignored due to path filters (4)
  • api-spec/protobuf/gen/ark/v1/admin.pb.go is excluded by !**/*.pb.go, !**/gen/**
  • api-spec/protobuf/gen/ark/v1/admin.pb.rgw.go is excluded by !**/gen/**
  • api-spec/protobuf/gen/ark/v1/admin_grpc.pb.go is excluded by !**/*.pb.go, !**/gen/**
  • api-spec/protobuf/gen/ark/v1/indexer.pb.rgw.go is excluded by !**/gen/**
📒 Files selected for processing (34)
  • Makefile
  • README.md
  • api-spec/openapi/swagger/ark/v1/admin.openapi.json
  • api-spec/protobuf/ark/v1/admin.proto
  • docker-compose.regtest.yml
  • envs/arkd.dev.env
  • envs/arkd.light.env
  • internal/config/config.go
  • internal/core/application/admin.go
  • internal/core/application/admin_test.go
  • internal/core/application/service.go
  • internal/core/application/types.go
  • internal/core/domain/settings.go
  • internal/core/domain/settings_repo.go
  • internal/core/ports/repo_manager.go
  • internal/infrastructure/db/badger/settings_repo.go
  • internal/infrastructure/db/postgres/migration/20260227000000_add_settings.down.sql
  • internal/infrastructure/db/postgres/migration/20260227000000_add_settings.up.sql
  • internal/infrastructure/db/postgres/settings_repo.go
  • internal/infrastructure/db/postgres/sqlc/queries/models.go
  • internal/infrastructure/db/postgres/sqlc/queries/query.sql.go
  • internal/infrastructure/db/postgres/sqlc/query.sql
  • internal/infrastructure/db/service.go
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/sqlite/migration/20260227000000_add_settings.down.sql
  • internal/infrastructure/db/sqlite/migration/20260227000000_add_settings.up.sql
  • internal/infrastructure/db/sqlite/settings_repo.go
  • internal/infrastructure/db/sqlite/sqlc/queries/models.go
  • internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
  • internal/infrastructure/db/sqlite/sqlc/query.sql
  • internal/interface/grpc/handlers/adminservice.go
  • internal/interface/grpc/permissions/permissions.go
  • internal/test/e2e/single_batch_smoke_test.go
  • internal/test/e2e/utils_test.go
💤 Files with no reviewable changes (2)
  • envs/arkd.light.env
  • envs/arkd.dev.env

Comment thread api-spec/openapi/swagger/ark/v1/admin.openapi.json
Comment thread internal/core/application/admin.go Outdated
Comment thread internal/core/application/admin.go Outdated
Comment thread internal/core/application/admin.go Outdated
Comment thread internal/core/application/service.go Outdated
Comment thread internal/infrastructure/db/badger/settings_repo.go
Comment thread internal/infrastructure/db/service.go
Comment thread internal/interface/grpc/handlers/adminservice.go Outdated
Comment thread README.md Outdated
@bitcoin-coder-bob

Copy link
Copy Markdown
Collaborator Author

addressed the comments from @arkanaai (#939 (comment)) in: 5271785

@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Thanks for addressing the feedback, the changes look good overall. A few notes on the commit:

✅ Locktime deduplication — clean, / is the right home.

✅ ClearSettings — now correctly re-seeds defaults and calls onSettingsUpdated. The test rename to "resets settings to defaults" reflects the actual behaviour. Good.

✅ Validate() — covers the critical cases (zero exit delays, min/max participants ordering, public ≥ unilateral). Two small follow-up items:

  1. Missing ordering constraint: VtxoTreeExpiry isn't checked against UnilateralExitDelay. Trees should expire before users need to exit, so probably want VtxoTreeExpiry > UnilateralExitDelay (or at least document why it's intentionally omitted).

  2. String-based error detection in the gRPC handler:

    This is fragile — if the error message ever changes, validation errors silently become codes.Internal instead of codes.InvalidArgument. Consider using a sentinel error or errors.As with a typed validation error instead.

Neither is a blocker, but worth a quick fix before merge.

@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

🔍 PR Review: Settings Domain with DB Persistence and Admin CRUD API

Reviewer: Arkana (automated)
Scope: +2514/-221 across 38 files — migrates operator settings from env vars to DB-persisted Settings domain with admin API


Summary

This PR moves ~15 operator-configurable parameters (exit delays, VTXO tree expiry, round limits, ban thresholds, amount bounds, etc.) out of environment variables into a new Settings domain entity backed by DB persistence (badger/sqlite/postgres). It adds three admin API endpoints (GET/POST /v1/admin/settings, POST /v1/admin/settings/clear) and enables hot-reload of these parameters into the running service without restart.

✅ What looks good

  • Clean domain modeling: domain.Settings with Validate() and SettingsRepository interface is well-structured.
  • Multi-backend support: Proper implementations for badger, sqlite, and postgres with migrations.
  • Default seeding on first startup: loadSettings() in config seeds defaults if none exist — good for fresh deployments.
  • Admin permissions properly gated: Macaroon permissions correctly map to EntityManager read/write.
  • Comprehensive tests: Unit tests for admin service settings CRUD and integration tests across all DB backends.
  • E2E tests updated: setupArkd() now calls updateSettings() to configure test parameters via API.
  • ClearSettings resets to defaults rather than leaving the system in an undefined state.

⚠️ Concerns

1. Race condition in service.UpdateSettings() (medium severity)

internal/core/application/service.goUpdateSettings() mutates ~15 fields on the service struct (s.banDuration, s.batchExpiry, s.unilateralExitDelay, etc.) without any synchronization. If a round is being processed concurrently, it could read a mix of old and new values mid-update. Consider holding the service mutex (or adding one if there isnt one) during the settings update, or using an atomic swap of a settings snapshot.

2. No guard against changing exit delays for live VTXOs (high severity — protocol correctness)

Changing unilateral_exit_delay, boarding_exit_delay, or vtxo_tree_expiry at runtime affects new rounds, but existing VTXOs were committed with the old values baked into their scripts. The system should either:

  • Document clearly that these changes only affect future rounds (and ensure no code path applies new delays to existing VTXOs), or
  • Reject changes to exit delays while there are unsettled VTXOs, or
  • At minimum, log a prominent warning when these critical parameters change.

This is the most security-sensitive aspect of this PR. An operator accidentally reducing unilateral_exit_delay mid-operation could create confusion about exit guarantees.

3. Postgres upsert ID defaults to 0 implicitly

In postgres/settings_repo.go, UpsertSettingsParams.ID is never set (defaults to 0). This works as a singleton pattern via ON CONFLICT(id), but its implicit. Consider either:

  • Using a constant settingsSingletonID = 1 and setting it explicitly, or
  • Adding a comment explaining the singleton pattern.

The same applies to sqlite/settings_repo.go.

4. Validate() could be stricter

domain/settings.go Validate() doesnt check:

  • VtxoTreeExpiry must be reasonable (e.g., > UnilateralExitDelay)
  • CheckpointExitDelay relationship to other delays
  • MaxTxWeight must be > 0
  • BanThreshold must be > 0

Some of these are enforced later in config.Validate(), but since UpdateSettings calls settings.Validate() directly, the API endpoint could accept values that config.Validate() would reject.

5. updated_at stored as unix timestamp loses sub-second precision

The postgres/sqlite schemas store updated_at as BIGINT (unix seconds). The badger store preserves Go time.Time directly. This means time.Unix(settings.UpdatedAt, 0) in the SQL repos truncates to seconds. Not a bug, but a minor inconsistency worth noting.

6. Settings schema uses BIGINT GENERATED BY DEFAULT AS IDENTITY (postgres)

This allows manual ID insertion (the 0 from point 3), but also allows auto-increment if multiple rows are inserted without explicit ID. Since the code always passes 0, this works, but GENERATED ALWAYS would be safer to prevent accidental multi-row scenarios. Or drop auto-generation entirely since this is a singleton table.

📝 Minor notes

  • Docker compose and dev env files cleanly remove the old env vars — good cleanup.
  • The Makefile simulation simplification (removing MIN/MAX params) is a nice side effect.
  • Proto and OpenAPI specs are consistent with the implementation.
  • minAllowedSequence correctly moved to domain.MinAllowedSequence as a public constant.

Verdict

Architecturally sound refactor that improves operability significantly. The main concerns are around race safety during hot-reload and protocol correctness guarantees when exit delays change for live VTXOs. These should be addressed before merge.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
internal/core/application/admin.go (2)

631-637: ⚠️ Potential issue | 🟡 Minor

Cached participant count fields not refreshed after settings update.

After UpdateSettings succeeds, a.roundMinParticipantsCount and a.roundMaxParticipantsCount still hold their startup values. This affects UpdateScheduledSessionConfig which uses these as fallbacks (lines 279-283, 304-308).

Suggested fix
     if a.onSettingsUpdated != nil {
         if err := a.onSettingsUpdated(ctx, settings); err != nil {
             return err
         }
     }
+    a.roundMinParticipantsCount = settings.RoundMinParticipantsCount
+    a.roundMaxParticipantsCount = settings.RoundMaxParticipantsCount
     a.onInfoChange()
     return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/admin.go` around lines 631 - 637, After
UpdateSettings succeeds the cached fields a.roundMinParticipantsCount and
a.roundMaxParticipantsCount are not being refreshed, so
UpdateScheduledSessionConfig still sees old startup values; after the successful
call to a.onSettingsUpdated (i.e. inside the same block where you handle the
non-nil callback) update the cached values from the new settings (assign
settings.RoundMinParticipantsCount and settings.RoundMaxParticipantsCount into
a.roundMinParticipantsCount and a.roundMaxParticipantsCount) before calling
a.onInfoChange so subsequent UpdateScheduledSessionConfig calls use the updated
fallbacks.

623-638: ⚠️ Potential issue | 🟠 Major

Persist-then-apply ordering can leave inconsistent state on callback failure.

If onSettingsUpdated fails after Upsert succeeds, the database contains the new settings while the runtime keeps the old configuration. Without transaction support (per the SettingsRepository interface), consider reversing the order: apply first, persist on success.

Suggested reordering
 func (a *adminService) UpdateSettings(ctx context.Context, settings domain.Settings) error {
     if err := settings.Validate(); err != nil {
         return fmt.Errorf("invalid settings: %w", err)
     }
-    settings.UpdatedAt = time.Now()
-    if err := a.repoManager.Settings().Upsert(ctx, settings); err != nil {
-        return err
-    }
     if a.onSettingsUpdated != nil {
         if err := a.onSettingsUpdated(ctx, settings); err != nil {
             return err
         }
     }
+    settings.UpdatedAt = time.Now()
+    if err := a.repoManager.Settings().Upsert(ctx, settings); err != nil {
+        return err
+    }
     a.onInfoChange()
     return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/admin.go` around lines 623 - 638, UpdateSettings
currently persists settings via Settings().Upsert before calling the runtime
callback onSettingsUpdated, risking DB/runtime divergence if the callback fails;
change the order so the in-memory/runtime application happens first (call
a.onSettingsUpdated(ctx, settings) if non-nil), only if that succeeds then call
a.repoManager.Settings().Upsert(ctx, settings), and finally call
a.onInfoChange(); ensure you still validate and set settings.UpdatedAt before
applying, and propagate errors from the callback or the Upsert unchanged so
failures abort without leaving the runtime/stored state inconsistent.
🧹 Nitpick comments (2)
internal/core/domain/settings.go (1)

39-69: Consider adding validation for CheckpointExitDelay and MaxTxWeight.

The Validate() method checks UnilateralExitDelay, BoardingExitDelay, and VtxoTreeExpiry for positive values, but omits CheckpointExitDelay and MaxTxWeight. For consistency and to prevent invalid configurations:

Suggested additions
 func (s Settings) Validate() error {
     if s.UnilateralExitDelay <= 0 {
         return fmt.Errorf("unilateral exit delay must be greater than 0")
     }
+    if s.CheckpointExitDelay <= 0 {
+        return fmt.Errorf("checkpoint exit delay must be greater than 0")
+    }
     if s.BoardingExitDelay <= 0 {
         return fmt.Errorf("boarding exit delay must be greater than 0")
     }
     // ... existing checks ...
+    if s.MaxTxWeight <= 0 {
+        return fmt.Errorf("max tx weight must be greater than 0")
+    }
     return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/domain/settings.go` around lines 39 - 69, Add validation in
Settings.Validate to check CheckpointExitDelay and MaxTxWeight are positive,
mirroring existing checks: after the existing
UnilateralExitDelay/BoardingExitDelay/VtxoTreeExpiry checks add an if
s.CheckpointExitDelay <= 0 { return fmt.Errorf("checkpoint exit delay must be
greater than 0") } and an if s.MaxTxWeight <= 0 { return fmt.Errorf("max tx
weight must be greater than 0") }, keeping the checks inside the
Settings.Validate function.
internal/config/config.go (1)

369-371: Consider inlining determineLocktimeType.

This function is now a simple wrapper around domain.ToRelativeLocktime. Consider removing the indirection:

-func determineLocktimeType(locktime int64) arklib.RelativeLocktime {
-    return domain.ToRelativeLocktime(locktime)
-}

Then call domain.ToRelativeLocktime() directly in loadSettings().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/config.go` around lines 369 - 371, The helper function
determineLocktimeType is an unnecessary wrapper around
domain.ToRelativeLocktime; remove the determineLocktimeType function and update
callers (e.g., loadSettings) to call domain.ToRelativeLocktime(locktime)
directly, ensuring any imports or references remain correct and tests/usage of
loadSettings continue to compile.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/application/admin.go`:
- Around line 640-656: ClearSettings currently does Clear + Upsert before
calling onSettingsUpdated and does not refresh cached participant counts; change
the ordering to match UpdateSettings by: perform
repoManager.Settings().Clear(ctx), set defaults.UpdatedAt, call
a.onSettingsUpdated(ctx, defaults) (if non-nil) before persisting, then Upsert
the defaults, and after successful Upsert refresh the cached participant counts
using the same helper used by UpdateSettings and finally call a.onInfoChange();
ensure you propagate any errors from onSettingsUpdated, the refresh helper, and
Upsert.

In `@internal/core/application/service.go`:
- Around line 295-340: UpdateSettings currently mutates shared service fields
(banDuration, banThreshold, unilateralExitDelay, publicUnilateralExitDelay,
checkpointExitDelay, boardingExitDelay, batchExpiry, roundMinParticipantsCount,
roundMaxParticipantsCount, vtxoMinAmount, vtxoMaxAmount, utxoMinAmount,
utxoMaxAmount, settlementMinExpiryGap, vtxoNoCsvValidationCutoffTime,
maxTxWeight, checkpointTapscript) without synchronization; add a settings mutex
(e.g., s.settingsMu sync.RWMutex) and wrap the entire UpdateSettings body with
s.settingsMu.Lock()/Unlock(), then change readers that access these fields
(examples: startRound, startConfirmation) to use s.settingsMu.RLock()/RUnlock()
when reading to prevent data races and ensure consistent view of settings.

---

Duplicate comments:
In `@internal/core/application/admin.go`:
- Around line 631-637: After UpdateSettings succeeds the cached fields
a.roundMinParticipantsCount and a.roundMaxParticipantsCount are not being
refreshed, so UpdateScheduledSessionConfig still sees old startup values; after
the successful call to a.onSettingsUpdated (i.e. inside the same block where you
handle the non-nil callback) update the cached values from the new settings
(assign settings.RoundMinParticipantsCount and
settings.RoundMaxParticipantsCount into a.roundMinParticipantsCount and
a.roundMaxParticipantsCount) before calling a.onInfoChange so subsequent
UpdateScheduledSessionConfig calls use the updated fallbacks.
- Around line 623-638: UpdateSettings currently persists settings via
Settings().Upsert before calling the runtime callback onSettingsUpdated, risking
DB/runtime divergence if the callback fails; change the order so the
in-memory/runtime application happens first (call a.onSettingsUpdated(ctx,
settings) if non-nil), only if that succeeds then call
a.repoManager.Settings().Upsert(ctx, settings), and finally call
a.onInfoChange(); ensure you still validate and set settings.UpdatedAt before
applying, and propagate errors from the callback or the Upsert unchanged so
failures abort without leaving the runtime/stored state inconsistent.

---

Nitpick comments:
In `@internal/config/config.go`:
- Around line 369-371: The helper function determineLocktimeType is an
unnecessary wrapper around domain.ToRelativeLocktime; remove the
determineLocktimeType function and update callers (e.g., loadSettings) to call
domain.ToRelativeLocktime(locktime) directly, ensuring any imports or references
remain correct and tests/usage of loadSettings continue to compile.

In `@internal/core/domain/settings.go`:
- Around line 39-69: Add validation in Settings.Validate to check
CheckpointExitDelay and MaxTxWeight are positive, mirroring existing checks:
after the existing UnilateralExitDelay/BoardingExitDelay/VtxoTreeExpiry checks
add an if s.CheckpointExitDelay <= 0 { return fmt.Errorf("checkpoint exit delay
must be greater than 0") } and an if s.MaxTxWeight <= 0 { return fmt.Errorf("max
tx weight must be greater than 0") }, keeping the checks inside the
Settings.Validate function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1b59888-eb73-40cb-9875-bd5d2eefd762

📥 Commits

Reviewing files that changed from the base of the PR and between da53ecf and 5271785.

📒 Files selected for processing (6)
  • internal/config/config.go
  • internal/core/application/admin.go
  • internal/core/application/admin_test.go
  • internal/core/application/service.go
  • internal/core/domain/settings.go
  • internal/interface/grpc/handlers/adminservice.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/interface/grpc/handlers/adminservice.go
  • internal/core/application/admin_test.go

Comment thread internal/core/application/admin.go Outdated
Comment thread internal/core/application/service.go Outdated
…x/min participants on ClearSettings, commenting, MaxTxWeight validation, badger settings repo ctx cancellation in retry loop in Upsert
@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

🔍 Follow-up Review — New Commits (da53ecf, a61a9f1)

Thanks for addressing the feedback, @bitcoin-coder-bob. This is a solid round of fixes.

✅ Issues Resolved

  1. Race conditionsettingsMu mutex now guards both UpdateSettings and ClearSettings. The comment on service.UpdateSettings correctly documents the caller-holds-lock contract. Fixed.

  2. ClearSettings inconsistency — Now properly resets roundMinParticipantsCount and roundMaxParticipantsCount on the admin service after clearing DB. Fixed.

  3. Apply-before-persist ordering — Settings are now applied to the live service first, then persisted. If onSettingsUpdated fails, the DB retains the previous valid settings. Good defensive pattern. Fixed.

  4. MaxTxWeight validationValidate() now rejects MaxTxWeight <= 0. Fixed.

  5. Badger retry respects contextselect on ctx.Done() prevents infinite retry loops if the context is cancelled. Fixed.

  6. README & comments — Endpoint table, ToRelativeLocktime godoc, and inline comments all improve clarity.

🟡 Remaining Concern (from prior review, still open)

No guard against changing exit delays for live VTXOs. If an operator reduces unilateral_exit_delay or boarding_exit_delay while VTXOs with longer timelocks are in-flight, those VTXOs were committed with the old delay baked into their scripts. The DB settings would diverge from what's actually enforced on-chain. This is a protocol correctness issue rather than a code bug — the validation layer doesn't have visibility into existing VTXO commitments.

This doesn't need to block merge, but a follow-up issue to add "minimum floor" validation (or at least a warning log when reducing exit delays) would be valuable.

🟢 Overall

The mutex, apply-before-persist, and validation additions are all the right calls. This is in much better shape. No new blocking issues.

@bitcoin-coder-bob

bitcoin-coder-bob commented Mar 18, 2026

Copy link
Copy Markdown
Collaborator Author

addressed @arkanaai comment for string based error detction (#939 (comment)) in: 2802d62

however for VtxoTreeExpiry > UnilateralExitDelay while we can normalize the comparison using RelativeLocktime.Seconds(), the block scheduler defaults would fail it (vtxoTreeExpiry=20 blocks ≈ 12,000s vs
unilateralExitDelay=86,400s), and config.go:Validate() doesn't enforce this either. Skipping for now to avoid breaking block scheduler configs. The bit inside defaultSettings() in config.go:

	if c.SchedulerType == "block" {
		vtxoTreeExpiry = 20      // 20 blocks
		checkpointExitDelay = 10 // 10 blocks
	}

this is the case that would break things, since now vtxoTreeExpiry is now not going to exceed UnilaterExitDelay. The Validate() method on Settings doesn't know which scheduler is in use. So it would either need the scheduler type passed in, or the check would have to be done at the config level instead. Either way it would break block scheduler configs as they exist today.

@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Makes sense — the block scheduler case is a legitimate exception and you're right that Validate() doesn't have the scheduler context to distinguish them. Adding the check there would either false-positive on block configs or require threading scheduler type through, neither of which is worth it here.

Skipping the VtxoTreeExpiry > UnilateralExitDelay check is reasonable. If it's worth documenting the invariant somewhere for future reference, a comment in Validate() or defaultSettings() noting "block scheduler configs intentionally have vtxoTreeExpiry < unilateralExitDelay" would help the next person not re-raise this.

Otherwise this looks good to me — both original blockers are resolved.

@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

🔍 Arkana Review — Follow-up (commit 2802d62)

Scope: Single commit since last review — introduces ErrInvalidSettings typed error.

Changes

  1. domain/settings.go — New ErrInvalidSettings struct with Reason field. All Validate() returns now use this typed error instead of raw fmt.Errorf.
  2. application/admin.goUpdateSettings now returns the validation error directly (no wrapping), preserving the error type for callers.
  3. grpc/handlers/adminservice.go — Replaces fragile strings.Contains(err.Error(), "invalid settings:") with proper errors.As(err, &validationErr). This is the right pattern — type assertions are stable across refactors, string matching is not.

Assessment

Clean improvement. The typed error approach:

  • Makes the gRPC handler resilient to message wording changes
  • Allows SDK consumers to programmatically distinguish validation failures
  • Follows Go error handling best practices (errors.As over string inspection)

No security, correctness, or cross-repo concerns with this change.

Prior Review Items Status

All previously flagged issues from earlier commits remain addressed. The one non-blocking concern from the prior review still stands: no guard against reducing exit delays for live VTXOs with existing timelocks. This is a protocol-level design decision, not a code bug.

No further action needed on this commit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/core/application/admin.go (1)

652-673: Note: ClearSettings applies to the running service after persisting, unlike UpdateSettings.

In UpdateSettings, onSettingsUpdated is called before Upsert to fail-fast on live-apply errors. In ClearSettings, it's called after Upsert. If onSettingsUpdated were to fail (unlikely given context snippet 1 shows it just assigns fields), the DB would have the new defaults while the runtime retains old values.

Since service.UpdateSettings (the concrete callback) only performs field assignments and cannot fail in practice, this ordering difference is low-risk. However, for consistency and defensive coding, consider aligning the ordering with UpdateSettings.

💡 Optional: Align ordering with UpdateSettings
 func (a *adminService) ClearSettings(ctx context.Context) error {
 	a.settingsMu.Lock()
 	defer a.settingsMu.Unlock()

 	if err := a.repoManager.Settings().Clear(ctx); err != nil {
 		return err
 	}
 	defaults := a.defaultSettings
 	defaults.UpdatedAt = time.Now()
+	if a.onSettingsUpdated != nil {
+		if err := a.onSettingsUpdated(ctx, defaults); err != nil {
+			return err
+		}
+	}
 	if err := a.repoManager.Settings().Upsert(ctx, defaults); err != nil {
 		return err
 	}
-	if a.onSettingsUpdated != nil {
-		if err := a.onSettingsUpdated(ctx, defaults); err != nil {
-			return err
-		}
-	}
 	a.roundMinParticipantsCount = defaults.RoundMinParticipantsCount
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/admin.go` around lines 652 - 673, The ClearSettings
workflow currently calls repoManager.Settings().Upsert(...) before invoking
a.onSettingsUpdated(...), risking the DB being updated while the runtime fails
to apply live changes; change ClearSettings to mirror UpdateSettings by calling
a.onSettingsUpdated(ctx, defaults) before attempting
a.repoManager.Settings().Upsert(ctx, defaults) so failures in onSettingsUpdated
cause an early return and avoid persisting inconsistent state (keep locking
behavior with a.settingsMu and leave subsequent assignments to
a.roundMinParticipantsCount/a.roundMaxParticipantsCount and a.onInfoChange()
as-is).
internal/core/domain/settings.go (1)

53-88: Consider adding validation for amount min/max ordering when both are set.

The Validate() method checks several constraints but doesn't validate that VtxoMinAmount <= VtxoMaxAmount and UtxoMinAmount <= UtxoMaxAmount when both are explicitly set (i.e., not -1). From the context, -1 appears to mean "unset/unlimited", so validation should only apply when both values are positive.

💡 Optional enhancement
 	if s.MaxTxWeight <= 0 {
 		return &ErrInvalidSettings{"max tx weight must be greater than 0"}
 	}
+	if s.VtxoMinAmount > 0 && s.VtxoMaxAmount > 0 && s.VtxoMinAmount > s.VtxoMaxAmount {
+		return &ErrInvalidSettings{"vtxo min amount must be <= vtxo max amount"}
+	}
+	if s.UtxoMinAmount > 0 && s.UtxoMaxAmount > 0 && s.UtxoMinAmount > s.UtxoMaxAmount {
+		return &ErrInvalidSettings{"utxo min amount must be <= utxo max amount"}
+	}
 	return nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/domain/settings.go` around lines 53 - 88, The Validate() method
on Settings lacks checks ensuring min ≤ max for amount pairs; add validation in
Settings.Validate to verify when both VtxoMinAmount and VtxoMaxAmount are set
(i.e., != -1) that VtxoMinAmount <= VtxoMaxAmount and similarly when both
UtxoMinAmount and UtxoMaxAmount are set that UtxoMinAmount <= UtxoMaxAmount,
returning &ErrInvalidSettings with clear messages like "vtxo min amount must be
<= vtxo max amount" and "utxo min amount must be <= utxo max amount" when the
checks fail.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/domain/settings.go`:
- Around line 26-31: Add explicit upper-bound checks in Settings.Validate to
ensure delay fields do not overflow uint32: validate that UnilateralExitDelay,
BoardingExitDelay, and VtxoTreeExpiry are each <= math.MaxUint32 and return a
descriptive ErrInvalidSettings (e.g., "unilateral exit delay exceeds maximum
uint32 value") on violation; this prevents the silent truncation when
ToRelativeLocktime casts an int64 to uint32.

---

Nitpick comments:
In `@internal/core/application/admin.go`:
- Around line 652-673: The ClearSettings workflow currently calls
repoManager.Settings().Upsert(...) before invoking a.onSettingsUpdated(...),
risking the DB being updated while the runtime fails to apply live changes;
change ClearSettings to mirror UpdateSettings by calling
a.onSettingsUpdated(ctx, defaults) before attempting
a.repoManager.Settings().Upsert(ctx, defaults) so failures in onSettingsUpdated
cause an early return and avoid persisting inconsistent state (keep locking
behavior with a.settingsMu and leave subsequent assignments to
a.roundMinParticipantsCount/a.roundMaxParticipantsCount and a.onInfoChange()
as-is).

In `@internal/core/domain/settings.go`:
- Around line 53-88: The Validate() method on Settings lacks checks ensuring min
≤ max for amount pairs; add validation in Settings.Validate to verify when both
VtxoMinAmount and VtxoMaxAmount are set (i.e., != -1) that VtxoMinAmount <=
VtxoMaxAmount and similarly when both UtxoMinAmount and UtxoMaxAmount are set
that UtxoMinAmount <= UtxoMaxAmount, returning &ErrInvalidSettings with clear
messages like "vtxo min amount must be <= vtxo max amount" and "utxo min amount
must be <= utxo max amount" when the checks fail.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a2021358-40fc-4c29-9846-8fa56c78afc1

📥 Commits

Reviewing files that changed from the base of the PR and between a61a9f1 and 2802d62.

📒 Files selected for processing (3)
  • internal/core/application/admin.go
  • internal/core/domain/settings.go
  • internal/interface/grpc/handlers/adminservice.go

Comment thread internal/core/domain/settings.go Outdated
@bitcoin-coder-bob

Copy link
Copy Markdown
Collaborator Author

for the commnets by @arkanaai in: #939 (comment) they were addressed in commit: 557ccad

@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Thanks. The changes in 557ccad look good:

  • Upsert comment (postgres + sqlite) — clearly documents the singleton id=0 pattern. Exactly what was needed.
  • Exit delay warnings in UpdateSettings() — the three log.Warnf calls on change cover the critical parameters. Good that they're logged before the values are applied, so the log captures old→new correctly.

One small thing: publicUnilateralExitDelay and checkpointExitDelay changes aren't warned on, only the three main ones. Probably fine since those are less security-critical, but worth being consistent if you want complete auditability.

No blockers from me on this PR.

@arkanaai

arkanaai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

🔍 Arkana Review — Commit 557ccad6 (warning logs + comments)

Scope: service.go — warning logs on exit delay changes; postgres/settings_repo.go & sqlite/settings_repo.go — doc comments on Upsert singleton pattern.

✅ Looks good

  • Exit delay change warnings — Exactly what was needed. The log.Warnf calls clearly communicate that changes only affect future rounds, not live VTXOs. Good operator visibility for a parameter that has protocol-level implications.
  • Clean refactor — Computing newUnilateral, newBoarding, newExpiry before comparison avoids redundant ToRelativeLocktime calls and makes the diff-then-assign flow readable.
  • Singleton key documentation — Clarifying the ON CONFLICT(id) / Go zero-value pattern removes a potential maintenance trap.

🟢 No concerns

This commit is additive (logging + comments only), no behavioral changes. LGTM.

Comment thread pkg/ark-lib/locktime.go Outdated
Comment thread README.md Outdated
| Endpoint | Method | Description |
|-----------------------------------|--------|------------------------------------------|
| `/v1/admin/settings` | GET | Retrieve current settings |
| `/v1/admin/settings` | POST | Update settings (full replace) |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isnt this endpoint partial update? since every field is optional?

Suggested change
| `/v1/admin/settings` | POST | Update settings (full replace) |
| `/v1/admin/settings` | POST | Update settings (partial; only provided fields change) |

Comment thread README.md
### Configuration Options

The `arkd` server can be configured using environment variables.
The `arkd` server can be configured using environment variables and the admin settings API.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the env vars are only used for seeding on first boot. I know we say that on line 110 in this file, but the reader is going to read this line before line 110. I think we need to also call that out here, that these vars are for seeding, and that each susequent boot of arkd the runtime reads the settings from the db and not these env vars

@bitcoin-coder-bob bitcoin-coder-bob Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats unclear to me is that we do not surface a comprehensize list of the first boot seed variables. having a list of these would make it clear what gets set to what right out of the box

}
txid := arkPtx.UnsignedTx.TxID()

settings, err := s.cache.Settings().Get(ctx)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theres a handful of cases in this file where we call s.cache.Settings().Get(ctx) and then set a bunch of local variables from the yielded settings. It feels like something that should go in its own function and have a corresponding settings struct. For example in this SubmitOffChainTx we could call a new function:

settings, err := GetSubmitOffChainTxSettings(ctx)
func (s *service) GetSubmitOffChainTxSettings(ctx context.Context) (settings OffChainTxSettings, err error) {
settings, err := s.cache.Settings().Get(ctx)
	if err != nil {
		return nil, errors.INTERNAL_ERROR.New("failed to get settings: %w", err)
	}
   settings: OffChainTxSettings{
	banThreshold: settings.BanThreshold,
	minAllowedExitDelay: settings.UnilateralExitDelay,
	vtxoNoCsvValidationCutoffDate: settings.VtxoNoCsvValidationCutoffDate,
	signerPubkey: settings.SignerPubkey,
	vtxoMinAmount: settings.VtxoMinAmount,
	vtxoMaxAmount: settings.VtxoMaxAmount,
	checkpointTapscript: settings.CheckpointTapscript,
	maxOpReturnOutputs: settings.MaxOpReturnOutputs,
	maxTxWeight: settings.MaxTxWeight,
	maxAssetsPerVtxo: settings.MaxAssetsPerVtxo(),
	allowCSVBlockType: settings.AllowCSVBlockType(),
}

return settings, nil
}

Comment thread internal/core/ports/live_store.go Outdated
MaxOpReturnOutputs: int64(s.MaxOpReturnOutputs),
}
// nolint
buf, _ := json.Marshal(data)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

swallowing the error, our prior digest construction code did not swallow the error. I think we need to return the error and upstream where this Digest() function is called we will need to handle the error

@bitcoin-coder-bob

Copy link
Copy Markdown
Collaborator Author

I summoned the AI who actually had some valid concerns:

Blocking / High

  1. The "singleton" is enforced by nobody, and three code paths disagree on what "the row" is.
  - Get → SelectLatestSettings = ORDER BY updated_at DESC LIMIT 1 (postgres/sqlite query.sql)
  - Upsert → INSERT ... ON CONFLICT(id) DO UPDATE with id defaulting to 0 (Go never sets it)
  - InsertSettingsHistory → hardcodes WHERE s.id = 0
  
  The schema (id GENERATED BY DEFAULT AS IDENTITY / AUTOINCREMENT) places no CHECK/unique guarantee that only one row exists. If a second row ever lands (a stray insert, a future code path that sets id,
  a botched restore), Get follows updated_at while upsert/history follow id=0, and they silently diverge. updated_at is second-granularity (timeToUnix uses .Unix()), so the ORDER BY tiebreak is also
  non-deterministic within a second. This is exactly the failure class a CHECK (id = 1) (or id = 0) singleton constraint prevents, and matches the "restore the singleton CHECK" instinct from earlier.
  Recommendation: enforce one row at the schema level and make all three paths agree on a fixed id (WHERE id = 1), rather than relying on three separate conventions.

  2. Every process restart writes a no-op settings_history row and fires a spurious "settings changed" event.
  Boot path service.go:103-116: resolveMinAmounts is idempotent, but settings.Update({VtxoMinAmount, UtxoMinAmount}) appends to the changelog based purely on pointer-non-nil, never value comparison
  (settings.go:293-308). So the changelog is always ["vtxo_min_amount","utxo_min_amount"] on boot → postgres Upsert sees a non-empty changelog → InsertSettingsHistory runs (settings_repo.go:156-161) → an
  audit row on every restart, even when nothing changed. This directly defeats the stated intent in that same code ("Only record history for actual changes"). It also means the very first history entry
  is a phantom reconciliation of the seed, and any "settings changed" warning logs fire on every boot. Recommendation: gate the write/history on an actual value delta (compare before appending to
  changelog, or only persist if resolveMinAmounts changed something), or compute resolved min-amounts for the cache without persisting.

  3. UpdateSettings is an unserialized read-modify-write, and the cache refresh is a fire-and-forget goroutine whose ordering isn't tied to the DB.
  - adminService.UpdateSettings (admin.go:585-611): Get → settings.Update → Upsert with no lock (confirmed: no mutex in admin.go). Two concurrent admin updates each read the same base and the later
  Upsert clobbers the earlier — classic lost update.
  - postgres/sqlite Upsert ends with go r.dispatch(...) (settings_repo.go:168), and the registered handler (service.go:347-365) does its own unsynchronized cache Get → mutate → Upsert. The cache's
  RWMutex makes each call atomic but not the sequence, and the dispatch goroutines for two updates can run in either order. Net result: the cache can settle on an older value than the DB. DB/cache
  divergence with no reconciliation until restart. Redis doesn't save you here — its Watch wraps an unconditional Set, so it provides no read-check (see nit below).
  
  Settings changes are rare, so the probability is low, but it's real and silent. Recommendation: serialize UpdateSettings (mutex or SELECT ... FOR UPDATE), and make the cache update synchronous/ordered
  relative to the committed write (or refresh the cache from the DB rather than from the in-flight settings value).

  ---
  Medium
  
  4. The UpdateSettings doc comment is not just stale, it's inverted (admin.go:588-592). It describes a removed updateFields mechanism and states "Fields not set in the request default to 0 so callers 
  must populate all fields." The actual behavior is the opposite: pointer-based partial merge, omitted fields are preserved. A maintainer trusting this comment would assume an omitted field zeroes out.
  Fix or delete.

  5. Validation gaps vs. stated intent (settings.go:107-214). MaxSatoshis (line 14) is defined and used nowhere in the repo — the "add satoshi upper bound on amounts" intent never landed. There's no
  vtxo/utxo_max_amount >= min check (you can set max=5, min=100 and it validates), and MaxOpReturnOutputs is unvalidated. Either wire MaxSatoshis + a min/max ordering check, or drop the dead constant.

  6. ClearSettings is a half-wired/dead feature. repo.Clear() (DELETE FROM settings) is implemented in all three backends but called from no non-test code, the permission map references
  /AdminService/ClearSettings (permissions.go:408) for an RPC that doesn't exist (no admin.proto entry, no handler, no app method), and the README documents /v1/admin/settings/clear. Decide: implement
  it, or remove the repo method + permission + README row. Note Clear() is also dangerous given (1): on the current schema it empties the table but the seed only re-runs at boot, so a runtime clear would
  leave Get returning nil.

  7. InsertSettingsHistory ... WHERE s.id = 0 can silently snapshot nothing. If the row's id is ever not 0, the INSERT ... SELECT ... WHERE s.id=0 inserts zero rows: the upsert succeeds, the audit record
  is silently lost. Same root cause as (1).

  8. Inconsistent locking / data race on the handler field. dispatch reads r.updateHandler without updateHandlerMu, which RegisterUpdatesHandler holds when writing (settings_repo.go:42-46, 181-185, same
  in sqlite/badger). Benign in practice (registered once at startup), but it's a genuine race under -race and the mutex is half-used. Use the lock on read, or make it set-once/atomic.

  ---
  Low / nits

  - 9. README (as discussed): "Update settings (full replace)" is wrong (it's partial); the /clear endpoint doesn't exist; and the seed ARKD_* vars were deleted from the env table yet are still
  functional and still used in docker-compose.regtest.yml. They need a "first-boot seed only" note.
  - 10. Terminology drift: domain uses RoundMin/MaxParticipantsCount but the user-facing error strings say "batch min/max participants count" (settings.go:205,209) and the handler uses
  batchMinParticipants. Pick one vocabulary.
  - 11. sqlite AUTOINCREMENT is unnecessary (and slightly costly) for a singleton; AssetTxMaxWeightRatio round-trips float32↔float64 differently across backends (sqlite casts, postgres native). Cosmetic.
  - 12. Redis Upsert (redis/settings.go:36-57) wraps an unconditional Set in Watch/TxPipelined with retry, but never reads the key, so the optimistic-lock machinery buys nothing over a plain Set.
  Harmless, but misleading.
  - 13. inmemory settingsStore.Get returns a zero-value ports.Settings (not an error/nil) when never populated — a footgun if ever read before boot population.

periodSec, durationSec int64
roundMin, roundMax int64
)
ssErr := tx.QueryRowContext(ctx, `

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we turn this into a generated query from query.sql

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, actually this and other queries were still in query.sql, and i dropped them because they are queries related to tables that are not there anymore.

altafan and others added 6 commits June 12, 2026 01:08
- Guard the admin read-modify-write settings flows (UpdateSettings and the
  scheduled-session/batch-fees mutators) with a mutex and dispatch the cache
  refresh synchronously, so concurrent updates can't lose each other or leave
  the cache behind the DB.
- Validate vtxo/utxo max >= min amounts and require max op return outputs > 0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add settings domain and repo

2 participants